iT邦幫忙

2022 iThome 鐵人賽

DAY 24
0

Abstract

reverse() 不像其他咩色需要帶入參數甚至是 callback,使用起來可以說是相對簡單,但它的演算法稍嫌複雜且有一些讓人迷惑的地方,因此這篇介紹 ECMAScript 的篇幅會較長,但有一些細節挺值得一看的!我們下面會利用 ECMAScript 的演算法實作一個簡單客製的 reverse() 函式 (範例的 Example 3)。

整篇會分成以下幾個部分:

  • 使用時機
  • 語法
  • 說明
  • 範例
  • 注意事項
  • ECMAScript
  • 結論

reverse() 這個 method 的全寫應該是 Array.prototype.reverse(),有興趣可以看 Day 2 的介紹,這邊會直接使用 reverse() 作為替代。

範例的 callback 都會使用箭頭函式做介紹,如果尚不熟悉的話可以參考 MDN 的介紹。

最後會透過分析 ECMAScript 來驗證是否有吻合,如果覺得 ECMAScript 有點艱澀難懂,我們在 Day 4 、Day 5 有介紹其相關術語可以幫助閱讀。


使用時機

當你想要反轉一個陣列時,reverse() 會將原陣列反轉後回傳給你,例如以下:

[0, 1, 2, 3] -> [3, 2, 1, 0]

反轉後,原本陣列的第 1 個元素會變成最後一個,而最後 1 個元素會變成第 1 個,以此類推...。


語法

reverse()

參數

不需要提供任何參數給 reverse()

Return Value

回傳順序被反轉後的原陣列,也就是原陣列的參照 (reference)。

注意回傳陣列是原陣列,而非一個反轉後的新陣列。

Mutability

會改動到原陣列。


說明

reverse() 會使用反轉的方式調換元素的順序,也就是說第 1 個元素會變成最後 1 個,而最後 1 個則變成第 1 個,以此類推...。

reverse() 會保留稀疏陣列中的 empty slot,例如以下:

24.3

reverse() 會變動原陣列並回傳原陣列的參照,所以如果有將回傳值指派給其他變數,這個變數所指向的陣列跟原陣列會是一樣的。


範例

Example 1 - 基礎用法

const names = ['Emma', 'Anita', 'Pedro', 'Damien']

const reversed = names.reverse()

console.log(reversed)
// ['Damien', 'Pedro', 'Anita', 'Emma']

console.log(names)
// ['Damien', 'Pedro', 'Anita', 'Emma']

注意回傳的是反轉後的原陣列參照。

Example 2 - 不改動的原陣列的方法

const names = ['Emma', 'Anita', 'Pedro', 'Damien']

const reversed_1 = [...names].reverse()
console.log(reversed_1)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reversed_2 = Array.from(names).reverse()
console.log(reversed_2)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reversed_3 = names.map(element => element).reverse()
console.log(reversed_3)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reversed_4 = names.filter(() => true).reverse()
console.log(reversed_4)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reversed_5 = names.reduce((newArray, name) => {
	newArray.push(name)
	return newArray
}, []).reverse()
console.log(reversed_5)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reversed_6 = names.slice().reverse()
console.log(reversed_6)
// ['Damien', 'Pedro', 'Anita', 'Emma']

const reverse_7 = names.concat().reverse()
console.log(reverse_7)
// ['Damien', 'Pedro', 'Anita', 'Emma']

console.log(names)
// ['Emma', 'Anita', 'Pedro', 'Damien']

為了避免改動到原陣列,這邊使用了 7 種方法對原陣列做淺拷貝 (shallow copy) 後再執行反轉。

Example 3 - 利用 ECMAScript 演算法做一個簡單的 reverse()

const array = [0, 1, 2, 3, 4]
console.log(reverse(array))
// [4, 3, 2, 1, 0]

function reverse(array) {
	const length = array.length
	const middle = Math.floor(length / 2)

	let lower = 0

	while(lower !== middle) {
		const upper = length - lower -1
		const lowerValue = array[lower]
		const upperValue = array[upper]

		array[lower] = upperValue
		array[upper] = lowerValue
	
		lower++
	}
	return array
}

這邊利用了 ECMAScript reverse() 的演算法概念做了一個簡單客製的 reverse() 函式,但要注意的是這是一個很不嚴謹的函式,因為我們略過了很多檢查跟執行步驟,但這邊證明了我們可以用同樣的演算法達到相同的目的。


注意事項

reverse() 被刻意做成通用的,擁有 length 屬性的類陣列物件都可以使用這個咩色;但要注意的是, 字串雖然也是類陣列的一種,但由於它是不可改變的(Immutable ),因此並沒有辦法適用這個咩色。


ECMAScript

24.1

reverse() 的演算法並沒有要求呼叫它的一定要是一個陣列,可以從步驟 1 跟 Note 2 得知,為了方便解釋,這邊一律使用陣列來說明;我們來驗證一下它是否被做成了一個通用的咩色:

24.2

演算法的前 2 個步驟都是用來做一些前置處理,包括轉型、確認長度。

步驟 3 非常重要,這邊利用陣列的中點 (floor(len / 2)) 作為迴圈的結束條件 - middle

而步驟 4 的 lower 便是回圈的計數,從 0 起始。

步驟 5 開始陣列反轉,迴圈會一直持續到陣列中點才停止,也就是 lower 等於 middle;這邊可以看到 reverse() 每次都會將陣列頭尾元素取出並將其互換,接著將範圍往內縮減,再重複同樣的頭尾取值互換,直到範圍縮減至中點 (middle) ,例如以下圖示:

24.4

可以看到步驟 5-d 跟 5-f 在每次取值前都會使用 HasProperty() 來檢查屬性是否存在,因此如果屬性不存在或為 empty slot, HasProperty() 都會回傳 false

reverse() 只會在屬性存在時才將值取出:

  • 如果 lowerupper 都存在,則將值取出並交換
  • 如果 lower 存在而 upper 不存在,則將 upper 的值設為 lower,並將 lower 刪除
  • 如果 lower 不存在而 upper 存在,則將 lower 的值設為 upper ,並將 upper 刪除
  • 如果 lowerupper 都不存在,則不會有任何動作

這邊非常值得注意的是 - 為什麼從演算法來看, reverse() 明明最後會將不存在的屬性於交換後刪除,但實際操作下來,陣列中的 empty slot 卻仍然被保留下來?打開 MDN 也寫著 reverse() 會保留 empty slot!

我想這是因為 陣列 這個特殊物件有著一個非常非常重要的特性 -
就是陣列的 length

實際上,掌控陣列長度的並非是實際存在陣列中的元素,而是陣列的 length 屬性,陣列為了維持這個 length,它會將不存在的元素補上一個 undefined,也就是我們常說的 undefined hole 或 empty slot,所以當你使用 delete 刪除陣列的某個元素時,它的確會被刪除,但 delete 並不會改變陣列的 length,因此陣列會再馬上依據 length 的長度來補上相對應的 empty slot,我們先來驗證一下:

const array = [ , 1, 2, 3, ,]
console.log(array)
// [empty, 1, 2, 3, empty]
console.log(array.length)
// 5

delete array[2]
console.log(array)
// [empty, 1, empty, 3, empty]
console.log(array.length)
// 5

上面可以看到 delete 刪除的元素仍然會被自動補上 empty slot,因此這邊可以推斷 reverse() 不是直接保留 empty slot 的元素,它應該有先將其刪除,只是 reverse() 並不會更動到陣列的長度,因此陣列會馬上將其補上相對應的 empty slot,讓陣列維持它該有的長度! 因為這個是陣列物件才有的特性,因此如果使用在仿照陣列的一般物件,不存在的屬性就真的會被刪除了,我們來驗證一下:

const array = [ , 1, 2, , 4,]
console.log(array.reverse())
// [4, empty, 2, 1, empty]

const fakeArray = {
	0: 0,
	2: 2,
	3: 3,
	4: 4,
	length: 6
}
console.log([].reverse.call(fakeArray))
// {1: 4, 2: 3, 3: 2, 5: 0, length: 6}

上面可以看到 array 原本交換後被刪除的元素被使用 empty slot 填充了,但 fakeArray 交換後不存在的屬性的確被刪除了,也就是屬性 04 在交換後被刪除了,關於陣列的更多特性我們會在 Day 30 來做討論。

出現 ? 的地方代表有可能會丟出錯誤,所以整個演算法有 12 處有機會丟出錯誤,例如步驟 5-h-i 的 Set(),當設值失敗便會丟出一個 TyperError 的錯誤;或者步驟 2 的 LengthOfArrayLike(),當物件的 length 為一個 Symbal 或 BigInt,便會從內部的 ToLength() -> ToIntegerOrInfinity() -> ToNumber() 丟出一個 TyperError 的錯誤,我們來驗證一下:

24.5

如果出現 ! ,則代表這個抽象操作 (abstract operation) 絕對不會丟出錯誤,例如步驟 5-b 的 ToString() 它會在參數是一個 Symbol 時丟出一個 TypeError,但我們確定丟進去的是一個 Number (F(upper)),因此不會有丟出錯誤的可能。

從 ECMAScript 的演算法來看,尚未找到與 JavaScript 實作的不同之處。


結論

reverse() 可以說是簡單好用又快速,但切記它會變動到原陣列,且回傳的值也是原陣列的參照,很容易不小心變動到原始資料卻不自知,如果站在 immutalbe 的角度,我們應該要儘量複製一個新陣列後再進行 reverse()

最後,希望大家可以開心地使用各種咩色,體驗它帶給你的便利,祝大家歸剛沒煩惱。


參考資源


上一篇
Day 23 咩色用得好 - Array.prototype.lastIndexOf
下一篇
Day 25 咩色用得好 - Array.prototype.concat
系列文
咩色用得好,歸剛沒煩惱 - 從 ECMAScript 偷窺 JavaScript Array method30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言